Skip to content

[SG-4885] feat(platform): tirith platform check — a pre-plan policy step, no platform changes, plus the reference docs and a site that publishes - #272

Merged
refeed merged 62 commits into
mainfrom
feat/gate-capable-engine
Aug 13, 2026
Merged

[SG-4885] feat(platform): tirith platform check — a pre-plan policy step, no platform changes, plus the reference docs and a site that publishes#272
refeed merged 62 commits into
mainfrom
feat/gate-capable-engine

Conversation

@refeed

@refeed refeed commented Aug 3, 2026

Copy link
Copy Markdown
Member

What

tirith platform check — the client behind the IaC Governance GitHub Action. It packs the
documents a policy needs, masks them, uploads them, runs them through a StackGuardian workflow, and
renders the verdict as a PR comment and a check run.

How the run is shaped

A check is an ordinary plan run whose workflow carries one prePlanWfStepsConfig entry pointing
at the tirith-iac-governance step template. That step exits 12, which tells the run controller to
complete the run and skip everything after it — so generate-terraform-plan never executes and the
plan action is never acted on. It is a dummy.

No platform repo changes at all. core, sg-run-controller and api are untouched — core#1235,
sg-run-controller#298 and api#1708 are all closed.

How the bundle reaches the step

Not through a run field. The bundle is PUT into the workflow's own artifact prefix, which the run
controller already syncs down into $LOCAL_ARTIFACTS_DIR before any step executes
(external.py:2524). The step is told which bundle to read; the run body names no archive. That is
what removed the last api dependency — no serializer field, no data.key, no ?contentType=.

The name is per commit, and travels per run. tirith-bundle-<sha7>-<tag>.tar.gz.

A single shared name would be one that two concurrent runs can overwrite — and the action derives one
workflow id per repository, so two open pull requests is the ordinary case, not a corner. One run
would then evaluate the other's code and report the verdict as its own, silently, on a merge gate.

Per-run naming is possible because core merges the run's TerraformConfig over the workflow's
(workflowruns/__init__.py:1646), so each run sends its own prePlanWfStepsConfig. The copy stored
on the workflow is only a fallback — ensure_workflow 409s for an existing workflow and updates
nothing. Verified on QA: the same entry sent as a top-level WfStepsConfig is silently discarded
for TERRAFORM workflows (core synthesises the steps), which is why it must travel inside
TerraformConfig. TerraformConfig is already declared on WorkflowRunSerializer, so still no api
change. The merge is shallow, so the entry is sent complete and nothing else goes in it —
terraformVersion and managedTerraformState keep coming from the workflow.

Two further constraints on the name: it must match none of the sync's exclude patterns (sg.*,
*__sg.*, *pci_*, the compliance globs) or it never reaches the container — the old __sg. prefix
existed precisely to keep it out of that sync — and it must not be tfstate.json, which at the
artifact root is a managed-state workflow's live state.

Accepted cost: growth. The artifact prefix has no lifecycle rule, neither sync passes --delete,
and api serves only GET and POST on artifacts, so bundles accumulate and every later run downloads
all of them. Correctness over transfer cost; delete_artifact is kept for a retention sweep.

On the content type: file_upload_url signs application/json whatever the filename, and S3 checks
the signature against the header the client sends, not the body. So the PUT sends application/json
with a gzip body; the stored object is merely labelled wrongly, which nothing reads.

Consequences worth stating plainly:

  • Because a check is a completed plan run, scheduled drift proceeds off it. Neither behaviour is
    obviously right; this one is at least not silent.
  • The dashboard reads these as plan runs whose plan step never executed.

The bundle's shape

The archive is a contract — the step reads its inputs from it, and other systems read it to see the
code a verdict came from:

plan.json          the masked plan
tfstate.json       the masked state, if supplied
infracost.json     the cost breakdown, if supplied
metadata.json      what this bundle is
code/              the terraform source, if any was packed

Documents stay at the root and that is load-bearing: the step joins those names onto the extraction
directory and treats absence as normal, so moving one under a prefix would not raise — every policy
would report unevaluated and the run would look like it passed with warnings.

metadata.json exists because a consumer holding only the bundle could not tell which repository or
commit produced it, which subdirectory code/ came from, or whether absent code meant "none wanted" or
"dropped for size". code.repo_path is the field that cannot be recovered any other way — members are
named relative to --source-dir, so --source-dir infra/prod means code/main.tf belongs at
infra/prod/main.tf. snake_case, VCS-neutral (provider sniffed from the host, unknown rather than
guessed), and every repository field nullable so a local run degrades honestly instead of inventing a
repo. Any credential in the URL is stripped before it is written — this file outlives the run.

Private repositories

A run sends VCSConfig: {} to suppress the checkout. The workflow keeps its own config so the
dashboard still shows the repository, but core resolves the run's copy as
data.get("VCSConfig", wfDetails.get("VCSConfig", {})) — a present empty value beats the workflow's.

Without it every platform-mode job on a private repository ERRORED in pre_0_step, before the step
ran: fatal: could not read Password for 'https://None@github.com'. Public repositories hid it
entirely, because an anonymous clone succeeds. It also means the clone stopped putting unmasked
source in the run workspace, which was quietly undercutting the point of masking client-side.

Fixed from a penetration test

Two findings, both reproduced before the fix and re-verified after.

Report spoofing (F1). Every plan- and policy-derived string was interpolated into the comment raw or
wrapped in a single backtick, and a backtick in the value closes that span so the rest renders as
markdown and HTML. A pull-request author controls the terraform a plan is built from, so they controlled
the report a reviewer reads — the tester produced a fake "all policies passed" banner and a link whose
text said app.stackguardian.io and whose href pointed elsewhere. The gate itself was never affected.

Now escaped once in the renderer, so the comment, the check summary and the job summary are all covered.
Two primitives, because the sinks are in two languages: a code span with a dynamic-length fence for
markdown contexts (inert by construction, rather than by enumerating dangerous characters), and
html.escape plus backtick-to-entity for values going inside <summary>, <code>, <sub> and the
href. The report named three sinks; there were eight. Verified against markdown rendered by a
CommonMark parser and an HTML parse of the anchor — asserting on the markdown source proves nothing,
since the payload is still there by design, inside a span where it is inert.

State documents leaked computed mirrors (F2). redact_plan already swept the plaintext of every
marked value across the whole document, precisely because a provider writes computed mirrors carrying
the same secret with no sensitivity marker — tags_all is the confirmed case. redact_state did not,
so a secret in a tag was masked at tags.Password and uploaded in cleartext at
tags_all.Password, then retained in the bundle. Same hole, worse place: state carries every attribute
of every resource. It now collects at all four masking sites and sweeps on the way out. Outputs leaked
the same way and the report did not mention it — a masked output's plaintext was discarded, so nothing
knew it was a secret, and the same value in an ordinary attribute stayed in cleartext.

Masking

Everything is masked client-side, before anything leaves the runner. redact_state handles both
state shapes — raw terraform state pull (top-level resources, per-instance
sensitive_attributes) and terraform show -json <state> (values.root_module.resources[].values
with parallel sensitive_values, plus nested child_modules). Only the first was handled
initially; the second shipped plaintext, found by E2E rather than by the unit suite, because every
masking test used the shape the code already understood.

Committed source ships as written, so a secret hardcoded in HCL still reaches the platform.
Documented; --source-dir "" opts out.

Verdicts

FAIL → failed, UNKNOWN → errored, APPROVAL_REQUIRED/WARN → warned, PASS/SKIPPED → passed
(or warned if the run paused), empty → no-policies (or errored if paused). A rule with no
result is UNKNOWN, never an implied pass — the rule this codebase holds to is never green when
nothing was evaluated
.

Exit codes, on both surfaces: 0 passed, 3 a policy failed, 1 no verdict could be reached.
3 only with --fail-on-error.

--fail-on-error now works on the local form too (tirith -policy-path … -input-path …), which
previously exited 0 pass or fail and so could not gate anything — the open-source path could only
block a merge by talking to StackGuardian. Default is still 0 for compatibility.

The discriminator is final_result, which is tri-state: True → 0, False → 3, None → 1. None
means every check was skipped, so nothing ran — not a pass. An earlier attempt gated on errors and
inverted both halves: that field carries the informational "these ids are not defined and have been
removed" note, so a genuine violation whose expression had a typo exited 1 while a policy naming an
unknown provider exited 3.

Verified on QA

Latest full E2E, on a private repository —
run 31665497319,
all seven cases green: plan, plan-file, state, two-phase, infracost, defaults, local.

That run covers the last commit that changes runtime behaviour. Everything on the branch since is
documentation, so it has not been re-run for those — stated rather than implied, so nobody reads the
green tick as covering more than it does.

The assertions run through the documented consumer path (run → bundlePathget_artifact → signed
URL → untar) and check the bytes that actually left the runner: masked documents at the archive root,
source under code/, the unmasked committed plan.json absent, and metadata.json carrying no
credential. Infracost priced for real ($23.832), so the cost policy compared a real number rather
than passing against a $0 that meant "could not price this".

Also confirmed: runs reach COMPLETED at on_0_tirith-iac-governance with generate-terraform-plan
carrying no status entry at all, and a control workflow with no pre-plan step still runs terraform.

Depends on StackGuardian/sg-run-controller#301, which fixes exit 12 being honoured only when the
step container was still running when polled. Without it the fast cases (source-dir: "") error.

Documentation

Three documentation PRs have merged into this branch since review started, so they are part of what
merges here.

The docs site never worked. deploy_docs.yml built Docusaurus and then published ./build, but
the site builds at ./documentation/build — so every push to main published an empty directory.
That is why gh-pages held nothing but .nojekyll and https://stackguardian.github.io/tirith/
returned 404. url and baseUrl were also still the create-docusaurus placeholders, which would
have broken every asset path on a project site. Both fixed, plus a build_docs.yml that builds the
site on pull requests — the site sets onBrokenLinks: 'throw', so a bad link used to be
discoverable only after merging.

14 new reference pages (#275): every provider with each operation type and its arguments, all 13
condition types, the eval_expression language, the policy schema field by field, a CLI and
exit-code reference, and a cookbook. The examples were executed rather than written from memory — 6
cookbook recipes, 11 provider policies and roughly 88 evaluator probes were run against the real CLI,
and the quoted output and exit codes are what came back.

Repositioning (#273): the README opened by describing a StackGuardian-coupled policy framework.
It now leads with what happens to a pipeline, and StackGuardian appears once, late, as the optional
platform mode. Also documents the exit-code contract, a pinned-version install, and a GitLab job —
and warns that pip install tirith fetches an unrelated PyPI project.

Corrected while writing it: tirith-policy-variables.md documented {{ max_epoch }}, but the
engine's pattern is {{ var.NAME }}. Anyone following that page got a policy comparing against the
literal placeholder string — a check that looks green while measuring nothing.

#274 added the same landing page as a standalone docs/index.html; it was merged and then reverted
by #276, leaving the Docusaurus homepage as the single copy.

Tests

562 across the suite: 551 pass, and the 11 that fail need a terraform binary and an editable install — prerequisites CONTRIBUTING does not currently mention.

Known limitation

ensure_workflow returns 409 for an existing workflow and updates nothing, so a workflow created
before this feature keeps its old TerraformConfig and gains no policy step. Fresh workflow ids are
required; this is why the E2E uses new ones.

Found while documenting, deliberately not fixed here

Each is a code change, and none is in scope for this PR. Recorded so they are not lost with the
branch:

  1. ExitStatus.ERROR_TIMEOUT = 2 is unreachable. A platform-run timeout raises SGError
    CheckErrorExitStatus.ERROR, so it exits 1. The enum, docs/platform-check.md and the
    README all document 2 for a timeout, and nothing can produce it.
  2. The kubernetes provider crashes on a single-document YAML — core unwraps one document to a
    dict, the handler iterates it as a list: TypeError: string indices must be integers.
  3. Infracost sums only the first projectreturn total_sum sits inside the
    for project in ... loop in both cost functions.
  4. sg_workflow returns "" for an unknown workflow_attribute, so a typo evaluates an empty
    string instead of erroring.
  5. tests/providers/sg_workflow/policy.json fails against its own input.json; tests/providers/policy.json
    and wfPolicy.json use argument and provider names that no longer match PROVIDERS_DICT.
  6. Docstrings in equals.py, not_equals.py, greater_than*.py and less_than*.py claim values are
    "automatically cast to the same type". No casting occurs.

refeed added 2 commits August 1, 2026 18:11
Fixed:
- Variable substitution mutated the caller's policy dict. Evaluating the same
  parsed policy twice (a policy set, or a retry) leaked substituted values from
  one evaluation into the next.
- An unsupported condition.type returned without setting result["result"],
  raising KeyError in the pretty printer far from the real cause. The consumer
  is hardened with .get("result", []) as well.
- Provider errors reported without a ProviderError severity were discarded and
  None was evaluated against the condition, so a typo'd operation_type read as
  a genuine policy violation. Five sites across four providers were affected.
  These are malformed provider calls, so they deliberately bypass
  error_tolerance -- that setting exists to tolerate missing data, not to mask
  a broken policy.

Added:
- meta.id/name/description/severity/enforcement/tags/remediation now reach the
  result document when declared. Absent keys are omitted, so output for a
  policy declaring none of them is unchanged.

Backward compatibility is pinned by tests/golden/json_policy_output.json,
captured before these changes and asserted byte-identical after them.
Runs an organization's policies against a plan, state or arbitrary JSON
document from CI or a laptop: masks the document locally, packs it with the
terraform source into an archive, uploads it, creates a StackGuardian run, polls
it and reports the verdict as JSON and/or markdown.

This moves the StackGuardian protocol out of the GitHub Action, where it was
GitHub-only, untestable off a runner, and unavailable to anyone driving the
platform from GitLab or a Makefile. No new runtime dependencies -- the whole
thing is stdlib urllib, so a runner needs nothing beyond tirith itself.

Subcommands are dispatched before the flat parser sees anything. argparse cannot
express an optional subcommand alongside options like `-policy-path`, and the
local-evaluation surface is a contract that test_output_compatibility.py asserts
byte-for-byte. Also fixes cli.main(args=...), which was ignored because
parse_args() was called with no argument.

Two bugs found while writing this:

  * APPROVAL_REQUIRED was missing from the poller's terminal statuses. It is a
    resting state, so a run that reached it spun until the timeout and was then
    reported as a tool failure -- an outage, rather than a finished evaluation
    waiting on a human. It now yields an `approval-required` verdict.
  * A file named state.json in the working directory was packed raw.
    `terraform state pull > state.json` is the documented way to produce one, so
    it routinely sits there unmasked, and it shipped in full beside the masked
    copy. plan.json / state.json / infracost.json are now always written by
    pack() from an already-masked object and never copied from the source tree.

Exit codes: 0 clean, 3 for a policy failure under --fail-on-error, 1 for an
unreachable platform or a run that produced no verdict -- the last regardless of
the flag, because a run with no verdict must never look like a pass.
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.43072% with 313 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/tirith/platform/check.py 51.20% 98 Missing and 3 partials ⚠️
src/tirith/platform/redact.py 79.87% 32 Missing and 35 partials ⚠️
src/tirith/platform/client.py 68.53% 42 Missing and 14 partials ⚠️
src/tirith/platform/cli.py 66.91% 35 Missing and 10 partials ⚠️
src/tirith/platform/archive.py 85.33% 16 Missing and 6 partials ⚠️
src/tirith/platform/report.py 92.39% 7 Missing and 7 partials ⚠️
src/tirith/platform/discover.py 94.00% 2 Missing and 1 partial ⚠️
src/tirith/core/core.py 87.50% 1 Missing and 1 partial ⚠️
src/tirith/platform/regions.py 96.29% 1 Missing and 1 partial ⚠️
src/tirith/prettyprinter.py 0.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/tirith/__init__.py 100.00% <100.00%> (ø)
src/tirith/cli.py 73.07% <100.00%> (+73.07%) ⬆️
src/tirith/core/policy_parameterization.py 100.00% <100.00%> (ø)
src/tirith/status.py 100.00% <100.00%> (+100.00%) ⬆️
src/tirith/prettyprinter.py 51.48% <0.00%> (+51.48%) ⬆️
src/tirith/core/core.py 86.22% <87.50%> (+4.00%) ⬆️
src/tirith/platform/regions.py 96.29% <96.29%> (ø)
src/tirith/platform/discover.py 94.00% <94.00%> (ø)
src/tirith/platform/report.py 92.39% <92.39%> (ø)
src/tirith/platform/archive.py 85.33% <85.33%> (ø)
... and 4 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new tirith platform check subcommand that runs StackGuardian policy evaluations against a plan/state/JSON document by packaging masked inputs + Terraform source into an archive, creating/polling a StackGuardian run, and emitting JSON/markdown verdict output. It also tightens several core/CLI behaviors to preserve existing output contracts and avoid previously observed failure/leak modes.

Changes:

  • Add a stdlib-only StackGuardian “platform” integration (client, check, archive, redact, report) plus extensive tests for polling, masking, archiving, and rendering.
  • Add CLI subcommand pre-dispatch (tirith platform ...) while preserving the legacy flat CLI surface and byte-identical --json output compatibility.
  • Fix core behaviors (policy var substitution mutability, unsupported evaluator result shape, provider bare error surfacing) and bump version/changelog.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/platform/test_report.py Tests for verdict computation and markdown rendering/truncation behavior.
tests/platform/test_redact.py Security-focused tests asserting redaction on serialized bytes for plan/state.
tests/platform/test_client.py Tests for StackGuardian client polling/terminal states and upload behavior.
tests/platform/test_archive.py Tests archive contents/exclusions and ensures masked docs win over disk files.
tests/golden/json_policy_output.json Golden output fixture used to pin legacy JSON byte compatibility.
tests/core/test_policy_parameterization.py Adds regression tests ensuring var substitution doesn’t mutate caller policy dict.
tests/core/test_output_compatibility.py New contract tests ensuring stable output shape/bytes for consumers.
tests/core/test_core.py Adds tests for unsupported evaluator result shape and provider bare error surfacing.
tests/cli/test_dispatch.py Tests for subcommand dispatch without breaking legacy flat CLI contract.
src/tirith/status.py Adds distinct exit code for policy-failed outcomes under --fail-on-error.
src/tirith/prettyprinter.py Avoids KeyError by tolerating missing result key in evaluator output.
src/tirith/platform/report.py Implements result summarization, verdict mapping, and markdown rendering.
src/tirith/platform/redact.py Implements plan slimming + marker-driven redaction and state masking.
src/tirith/platform/client.py Implements stdlib-only StackGuardian API client including polling and artifact fetch.
src/tirith/platform/cli.py Implements tirith platform argparse surface and exit-code semantics.
src/tirith/platform/check.py Orchestrates read→mask→pack→upload→run→poll→fetch→report flow.
src/tirith/platform/archive.py Builds tar.gz archive with exclusions and reserved-name handling.
src/tirith/platform/init.py Introduces platform package with stdlib-only intent documented.
src/tirith/core/policy_parameterization.py Switches var substitution to operate on a deep copy to avoid mutation leaks.
src/tirith/core/core.py Ensures unsupported evaluator still populates result; passes through policy meta keys.
src/tirith/cli.py Adds pre-dispatch for subcommands and fixes main(args=...) honoring provided argv.
src/tirith/init.py Version bump to 1.2.0.
setup.py Updates package version to 1.2.0.
CHANGELOG.md Documents 1.2.0 release changes and notes/contracts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tirith/platform/report.py Outdated
Comment thread src/tirith/platform/archive.py Outdated
Comment thread src/tirith/platform/client.py Outdated
refeed added 3 commits August 3, 2026 12:35
A third instance of the `planned_values` pattern, caught by a live GitHub Action
run: a hardcoded value is masked in `resource_changes` and sits in plaintext in
the same document under
`configuration.root_module.resources[].expressions[].constant_value`, which
carries no sensitivity markers at all.

`configuration` cannot be dropped -- three operations read it -- so the literals
are scrubbed while the reference graph is kept. Lossless:
direct_references_operator reads only `references` and
direct_dependencies_operator only `depends_on`
(providers/terraform_plan/handler.py:329, :385-388).

Covers nested block arguments, repeated blocks (a list of expressions), child
modules via module_calls[].module, and variable `default` / output `expression`
literals.

Note this does not make a plan safe to hand out: the project archive carries the
terraform source as written, so a secret hardcoded in HCL still reaches the
platform in main.tf. Documented in the action's README rather than papered over.
…c tfstate.json

sensitive_attributes is a list of PATHS -- each entry is itself a list of steps:

    [[{"type": "get_attr", "value": "content_base64"}],
     [{"type": "get_attr", "value": "content"}]]

The code read only the flat forms, so on real state every entry was skipped: a
list is neither a dict nor a string. Nothing in a resource's attributes was
masked at all. The unit test passed because its fixture invented the flat shape;
verified now against `terraform state pull` output for a local_sensitive_file,
which is where the real shape came from.

Paths can also descend through nested objects and list indices, so the masker
walks them rather than assuming a single key, and deep-copies so the caller's
document is not mutated underneath it.

Renames the archive's state document from state.json to tfstate.json, matching
the TfStateCleaned fact it feeds and the name the terraform step already uses
for state. No collision: the archive unpacks into the user directory, while
managed state lives at the artifacts root, and policy-only forces
managedTerraformState off.
A rule result of APPROVAL_REQUIRED means its author wrote
`onFail: APPROVAL_REQUIRED`. The policy-only step records that without pausing
the run -- deliberately, since exit 11 would leave the poller spinning -- so the
run comes back COMPLETED and only the counts carry the intent.

Folding it into `warned` was wrong. `warned` maps to a `neutral` check, which
SATISFIES a required status check, so a policy demanding human sign-off silently
did not block. Ranked above `warned` it produces the `approval-required` verdict,
which the action maps to `action_required` -- honouring the author's intent
without implementing the approval workflow, which is out of scope here.

Caught by a live run against a real APPROVAL_REQUIRED policy: the rule reported
correctly and the verdict said `warned`, so the code handling
`approval-required` was unreachable from this path.
@refeed refeed changed the title feat(platform): add tirith platform check [SG-4885] feat(platform): add tirith platform check Aug 4, 2026
@notion-workspace

Copy link
Copy Markdown

Tirith GHA

… endpoint

Four changes to make `tirith platform check` runnable with no configuration, and to stop the
CLI depending on an endpoint that is being withdrawn.

regions.py replaces four hardcoded host literals with one table. --region names both URLs at
once, because setting only --api-url was leaving every run link in every PR comment pointing
at the wrong environment -- which reads as a broken integration rather than a
misconfiguration. Explicit URLs still win, permanently, since they are the only way to reach
a self-hosted or dedicated host. Combining --region with an explicit URL is an error rather
than a silent precedence rule. by_id raises on an unknown id instead of falling back to the
first region the way the Raycast extension does: a typo would otherwise point a US org at
production EU and surface only as an unexplainable auth error.

normalize_api_url accepts a base with or without /api/v1. tirith's flag has always included
it while sg-cli, Raycast and the terraform provider all omit it, so a SG_BASE_URL exported
for sg-cli produced 404s here.

discover.py finds plan.json or tfplan.json in the source directory when nothing is named, so
a caller in the conventional layout needs no flags at all. Two matches is an error rather
than "first one wins" -- silently evaluating the wrong document reports a verdict about
infrastructure nobody asked about, and it looks like a pass. --plan-file renders a binary
plan through `terraform show -json` straight into the masker, so no unmasked plan JSON is
written to disk. Binary resolution tries terraform-bin and tofu-bin BEFORE terraform and
tofu: setup-terraform installs a JS wrapper under the plain name whose setOutput('stdout')
would copy the entire plan into $GITHUB_OUTPUT, readable by every later step in the job.
test_the_plan_never_reaches_github_output pins that.

--workflow-id is now validated against the platform's own slug rule before any HTTP call.
It is interpolated unquoted into every API path, so a value like `live/prod/vpc` produced a
malformed URL rather than a usable error; the message suggests a slug that would work.

upload_archive moves from configuration_upload_url to file_upload_url, which is the same view
and the same core call and already produces a byte-identical key -- confirmed against QA. The
key now comes from `data.key` rather than a bespoke `msg` object, so `msg` stays the bare URL
string every other consumer reads. contentType is requested explicitly so the signature
matches the PUT header.

The archive uploads as `__sg.<tag>.tar.gz`. The prefix is load-bearing: the artifact prefix is
synced into every subsequent run of the workflow and re-uploaded with no --delete, so an
unexcluded name accumulates forever. `sg.` is not enough -- the awscli patterns match the key
relative to the sync source and the archive sits under a per-commit folder, so only the
`*__sg.*` / `*/__sg.*` patterns catch it at that depth.
@refeed refeed changed the title [SG-4885] feat(platform): add tirith platform check [SG-4885] feat(platform): tirith platform check — region key, document discovery, shared upload endpoint Aug 4, 2026
refeed added 2 commits August 5, 2026 07:25
…r the run

Three changes, all about what is left behind.

The run facts become the primary source of policy results, and the results artifact is only
consulted when the facts come back empty -- i.e. an older step image that still writes it.
That reverses the previous order, which existed only because the facts endpoint answered
"does not exist" for every run. It turned out to be a key mismatch in the run controller
rather than a missing record.

Fixing that exposed a second bug: get_policy_results read `body.get("signedUrl")` while the
endpoint returns `signed_url`, so the facts path always fell through to {}. It went unnoticed
for exactly as long as the results artifact was covering for it. Now goes through
_extract_signed_url, which already handles both spellings.

The project archive is deleted once the run reaches a terminal state. Nothing prunes the
artifact prefix -- there is no lifecycle rule and neither sync passes --delete -- so an
archive left behind is one permanent object per commit, per workflow, forever. Measured on the
QA e2e workflow: 27 permanent directories, 10 of them archives, all pulled into every later
run's working directory.

That required flattening the archive name from `<sha7>/__sg.<tag>.tar.gz` to
`__sg.<sha7>-<tag>.tar.gz`. Not cosmetic: a nested name is swallowed by the authorizer's
greedy <path:wfGrp> converter, so `DELETE .../artifacts/<sha7>/<name>/` matches
`DELETE .../wfgrps/<wfGrp>/` -- the workflow-group delete -- and is checked against entirely
the wrong permission. Verified against auth's own matcher. Keeping the sha and tag in the
filename preserves uniqueness, so two pull requests uploading concurrently still cannot
overwrite each other's archive before their runs start. Deletion is best-effort: it happens
after the verdict is known, so a failure warns and changes nothing.

--repo-url and --repo-ref record the source repository on the workflow via GIT_OTHER -- the
connector-less provider, which with isPrivate false needs no auth and skips the GitHub repo-id
extraction that rejects anything it cannot parse. It is metadata only: core pops iacVCSConfig
from the run's RuntimeParameters whenever terraformProjectZip is set, and the runner takes the
archive branch of its if/elif regardless. Set on creation only, so a workflow that already
exists keeps its blank repo field.
urlencode stringifies None to the literal "None", and the endpoint treats any non-empty
folder as a subfolder -- so the archive landed at .../artifacts/None/__sg.<sha>-<tag>.tar.gz.
Two consequences, both silent: a bogus None/ directory in the workflow's artifact prefix, and
a nested key that the post-run delete could not address, so cleanup no-opped on a 404 and the
archive persisted anyway.

Caught on a live QA run. The folder is now sent only when set; the archive passes none, which
is what puts it at the artifacts root where it can be deleted.
@refeed

refeed commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Update: artifact cleanup, source repo, and a live E2E

Three additions since the last review, plus a full end-to-end run on QA against a freshly created private repo using the zero-config invocation.

Run facts are now the primary source of results

get_policy_results was reading body.get("signedUrl") while the endpoint returns signed_url, so the facts path always fell through to {}. It went unnoticed for exactly as long as the results artifact was covering for it. Now goes through _extract_signed_url, which already handled both spellings.

With that fixed, the step stops writing tirith-results.json altogether (StackGuardian/workflow-step-templates#310) — it carried exactly the PolicyEvalResults the facts already hold, and only existed because the facts endpoint answered "does not exist" for every run.

The project archive is deleted after the run

Nothing prunes the artifact prefix: no lifecycle rule, and neither sync passes --delete. Measured on the QA e2e workflow before this change — 27 permanent directories, 10 archives and 17 results files, every one downloaded into every later run's working directory.

The archive name flattens from <sha7>/__sg.<tag>.tar.gz to __sg.<sha7>-<tag>.tar.gz. Not cosmetic — verified against auth's own matcher:

path DELETE resolves to
artifacts/2fa49a0/__sg.default.tar.gz DELETE .../wfgrps/<wfGrp>/the workflow-group delete
artifacts/__sg.2fa49a0-default.tar.gz the <artifact> route ✓

Uniqueness moves from the folder into the filename, so two PRs uploading concurrently still cannot collide.

--repo-url / --repo-ref

Records the source repo on the workflow via GIT_OTHER — the connector-less provider, which with isPrivate: false needs no auth. Metadata only: core pops iacVCSConfig whenever terraformProjectZip is set, and the runner takes the archive branch regardless.

A bug this PR introduced and the E2E caught

The first live run uploaded to artifacts/None/__sg.d1ecf60-default.tar.gz. urlencode stringifies None to the literal "None", and the endpoint treats any non-empty folder as a subfolder — so a bogus None/ directory appeared and the archive sat at a nested key the delete could not address, so cleanup silently no-opped on a 404. Fixed in cbc397c, with a parametrized regression test over None and "".

Worth stating plainly: the unit tests passed throughout. Only the live run surfaced this.

E2E evidence

Fresh private repo, created for this: refeed/tirith-e2e-08050726 — fixture with two null_resources, one tagged and one not, plus a local_sensitive_file fed from a sensitive variable.

The workflow uses the release-gate form — no with: block at all:

env:
  SG_API_TOKEN: ${{ secrets.SG_API_TOKEN }}
  SG_ORG: ${{ vars.SG_ORG }}
steps:
  - run: terraform show -json tfplan > plan.json
  - uses: StackGuardian/sg-cli-gh-action@feat/tirith-policy-check

Run after the fix

Masked 2 sensitive value(s) before upload
Packed 0 file(s) and 1 document(s) into 0 KB
Uploaded: orgs/demo-org/wfs/3HTTyXglyE1ZgEJlOwO2xLkPtQ5/artifacts/__sg.942141f-default.tar.gz
  • Packed 0 file(s) — the terraform source is not uploaded by default, as intended
  • Flat key, no None/

Results, read from wfrunfacts/default/ with no results artifact anywhere:

policy rule result
DO_NOT_TOUCH cost-control PASS
best-practices Policy-Rule-1 WARN
tirith-e2e-must-fail no-null-resources FAIL

Check run Tirith Policy: failure — 1 failed, 1 warned, 1 passed; sticky comment rendered with per-rule detail and the failing resource address (null_resource.untagged). Job stayed green because fail-on-error defaults false.

The artifact prefix after the run:

sub-prefixes: (none)
objects:      (none)

Empty. Workflow record confirms GIT_OTHER | https://github.com/refeed/tirith-e2e-08050726 | ref = add-storage, WfType: TERRAFORM, WfStepsConfig: [], action policy-only.

One caveat, measured rather than predicted

On a private repo the async repo-insights/security-scan lambda that fires on workflow creation settles at scan_status: "error" (not in_progress as I guessed), with "Something went wrong while scanning your repository". It cannot fail the create — separate thread, broad except — but it is user-visible on the workflow. Worth deciding whether to suppress it for archive-based workflows.

Deployed to QA and verified live: file_upload_url returns data.key, configuration_upload_url is 404, an unsupported contentType is rejected.

422 passed in tirith, 26 in the action, 95 in the step.

refeed added 9 commits August 5, 2026 10:36
"policy-only" described what the action does not do. "tirith-check" names the thing it
runs, matches the CLI subcommand (tirith platform check) and the action users add to their
workflow, so the same word appears at every layer.

Nothing has shipped under the old name -- it exists only on these branches and in QA test
runs -- so there is no alias and no migration. The action is a per-run RuntimeParameter, not
stored on the workflow, so existing workflows simply get the new value on their next run.
…d show cost in the comment

Infracost and Checkov read `planned_values` and nothing else. The masker drops terraform's
copy -- correctly, because it mirrors every value with NO sensitivity markers, so masking
`resource_changes` leaves the same secret in plaintext there, and a real plan leaked a
`local_sensitive_file` body through exactly that path.

The consequence was that both tools returned a clean, empty and entirely wrong answer.
Measured against infracost 0.10.27 with a real API key, same binary, same plan, differing only
by this section:

    with planned_values     totalMonthlyCost 39.8   1 priced resource
    without (what we ship)  totalMonthlyCost 0      0 priced resources

So the estimate was never a key problem. QA's image key works -- the last run returned
well-formed infracost JSON with no error, just nothing in it.

redact_plan now rebuilds `planned_values` from the *masked* `resource_changes`, after
_mask_by_marker has run. Same data, same shape, no unmarked copy. Only `after`, and only for
resources that will exist: a destroy has no planned value. Module resources are grouped under
`child_modules`; verified that flat and nested forms price identically, and both tools address
resources by the full `address`, which already encodes the module path.

The pull-request comment now carries a cost line, with the delta from the change when infracost
supplies one. Rendered even at zero or on failure, because silence is indistinguishable from
"this change costs nothing" -- very different things to tell a reviewer. It sits outside the
truncation path, so a wall of findings cannot push it out of the comment. Also surfaced as
`monthly_cost` in --output-json for a caller aggregating several units.

client.get_run_facts replaces the narrower get_policy_results as the fetch: the document
carries the verdict and the cost, and embeds the whole plan, so fetching it twice is worth
avoiding. get_policy_results stays as a thin accessor.

196 tests pass, 17 new -- including that the rebuilt section carries __SG_REDACTED__ rather
than the secret, and that terraform's original copy is replaced rather than merged.
A Checkov policy rendered as `❌ best-practices › Policy-Rule-1` with an entirely blank
<details> body -- twelve real findings (EC2 detailed monitoring, EBS encryption, IMDSv1, S3
KMS encryption) reduced to nothing, in the one place a reviewer looks. The verdict was right;
the reasons were invisible.

_extract_detail only understood tirith's shape: a list under `result`, each carrying `message`
and `meta.address`. Checkov entries are `{"description", "keys"}`, so every loop found nothing
and appended nothing. Both shapes now render.

`keys` are reduced to the resource address: Checkov reports `<type>.<name>.<attribute path>`
and the path can be arbitrarily deep, so
`aws_s3_bucket.data.rule.apply_server_side_encryption_by_default.sse_algorithm` becomes
`aws_s3_bucket.data`. The suffix is what the check inspected; the address is what a reviewer
navigates by, and reducing it also collapses several keys on one resource to a single entry.

Tests use the exact payload from QA run iqkxb26uzi1n rather than an invented fixture -- a
fixture is what let this through, since the renderer was only ever exercised against the shape
it already understood. Malformed keys are parametrized, and two tests pin that the tirith
shape and the engine-error path still work.

Also adds CHANGELOG_2026-08-05.md and updates the roadmap: the facts table now reflects that
PolicyEvalResults comes from the run facts rather than a per-run artifact, that Infracost is
written on every run, and that TfStateCleaned is deliberately not written by tirith-check.
…e no longer true

Updated against what is now verified on QA rather than what was true when it was written:

- TfStateCleaned moves from ⚠️ "deliberately not written" to ✅. A post-apply check now
  updates the workflow's Resources view. The reasoning that kept it out was half right: the
  shape mismatch was real and is what the conversion fixes; the workflow-scoped pointer is the
  *intent* for a post-apply check, not a hazard.
- Infracost moves from ⚪ "not exercised" to ✅ generated on every run.
- The archive is now flat and deleted after the run, so the "where it lands" row said something
  that stopped being true.
- A new section records the two-phase pipeline with the facts each phase writes, and why a
  policy with no document on one pass reports WARN.

Three corrections rather than additions:

- "TfStateCleaned and TfPlan are unreachable" was the old symptom of the wfrunfacts bug. Both
  are reachable; the bug is that wfrunfacts 404s on shared-ec2, and its scope is narrower than
  first described -- external.py was never affected, which is why the E2E kept working after
  the fixes were reverted out of this batch.
- The Infracost `$0` finding is added to the ship-blocking table with the evidence that
  isolates it to the image's key: the same plan prices at $35.99 locally, and an invalid key
  reproduces QA's output exactly while a missing key errors loudly.
- Residual `policy-only` references renamed.

Also adds CHANGELOG_2026-08-05.md: everything that changed today, each item linked to the run
that proves it.
The archive is the source that produced the findings, and another system reads it to generate
autofixes. Deleting it after the run removed the only copy of what was actually evaluated.

Retaining it is safe for the runs themselves: the `__sg.` prefix keeps it out of the per-run
artifact sync, so it never lands in a later run's working directory -- which was the problem
worth solving. It is not free, and the code says so: nothing prunes this prefix, so it is one
object per commit and tag, kept indefinitely, and it wants an S3 lifecycle rule.

No fact is written to point at it, because the pointer already exists. The key is on the run
record as RuntimeParameters.terraformProjectZip, verified on a live QA run, so a consumer
holding only a run id can reach the bundle with no platform change and nothing duplicated:

  GET .../wfruns/<id>/                     -> RuntimeParameters.terraformProjectZip
  GET .../wfs/<wf>/get_artifact/?artifactPath=<basename>  -> the bytes
  GET .../wfruns/<id>/wfrunfacts/default/  -> PolicyEvalResults

The plan called for recording the key in SGCustomWorkflowRunFacts. That is dropped: it would
copy data already on the record into a second place that can disagree with it, and the step
cannot see terraformProjectZip anyway -- only wfStepInputData reaches the container, so it
would have needed a core change to carry a value the consumer can already read.

`archive_key` is added to --output-json for a caller that has the result document in hand.
`client.delete_artifact` stays: it is tested, and a retention sweep will want it.

Note for consumers: the archive holds the masked plan and, only when `source-dir` is set, the
terraform source. The default ships no source, so autofix callers must set it or they will get
a bundle with nothing to fix.
A state document uploaded with --state-path was only reachable by unpacking the
run's archive, so it appeared in neither the State view nor the artifacts list.
It is now also written to `artifacts/tfstate.json`.

That name is canonical rather than chosen: the managed-state backend writes it,
state locking keys on the literal basename, and the state-backends listing
special-cases it. So no new API endpoint is needed either -- `tfstate_upload_url`
and `file_upload_url` are the same view, and its default filename is already
`tfstate.json`.

Unlike the archive this object is deliberately NOT `__sg.`-prefixed: it is meant
to be seen.

Two guards, because the same property that makes the name useful makes it
dangerous:

  * If the workflow manages its own terraform state, the upload is skipped. For
    such a workflow that object IS the live state, and writing a masked document
    over it is data loss. An unreadable answer counts as managed -- absent is not
    the same as false, and not being able to tell is not a reason to overwrite.
  * The log says the published copy is masked and cannot be used to run
    terraform. A file at the canonical state key full of __SG_REDACTED__ is a
    footgun for whoever downloads it next.

The upload is best-effort: a run whose policies evaluated correctly must not go
red because a convenience copy could not be written.

`upload_archive` becomes `upload_file` with a content type, since a JSON state
document cannot be sent with the archive's `application/gzip` -- S3 signs the
content type into the URL. Its body parameter is named `content`: calling it
`payload` shadowed the response variable and sent the JSON response to S3 in
place of the file, which an existing test caught.

376 tests pass, 10 new.
The terraform source is packed by default, so an exclusion that does not fire --
a committed vendor directory, a build output tree -- turned a working policy
check into a failed run. `archive.pack` raises above 100 MB gzipped and nothing
caught it: the pack call sat outside run_check's try block.

That trade is the wrong way round. The verdict gates the merge; the source is a
convenience for whatever reads the bundle afterwards. So an oversized archive now
degrades to documents-only and says so, loudly, instead of taking the check down
with it.

Only when a source tree was actually requested. Already documents-only and still
over the limit means the *documents* are too big and there is nothing left to
drop, so that stays fatal -- uploading an archive with no documents is not a
check at all.

The result document records `source_packed` and `source_skipped_reason`, because
"the bundle has no code" and "no code was wanted" have to be distinguishable by
a consumer that only has the document. The GitHub annotation is raised by the
action, not here: this module stays VCS-agnostic so a GitLab or Jenkins caller
reuses it unchanged.

Two things fixed while in here:

  * The size message reported anything under a megabyte as "0 MB, over the 0 MB
    limit" from integer division. It is now human-readable, which matters because
    the message is surfaced on a pull request.
  * MAX_ARCHIVE_BYTES is overridable via TIRITH_MAX_ARCHIVE_BYTES. With the
    source packed by default, the only other lever was dropping it entirely, so a
    large monorepo that genuinely needs to ship its code had nowhere to go. A
    non-numeric value is ignored rather than failing a run.

214 platform tests pass, 4 new.
The pull-request comment is edited in place across runs, so it shows the latest
verdict and nothing else. Without naming the revision, a reader has no way to
tell whether what they are looking at is about the head of the branch or about a
push from an hour ago -- and the more confident the verdict reads, the worse that
ambiguity is.

`render_markdown` takes an optional `commit`, rendered as a subline under the
headline. Doing it here rather than letting the caller append means the check-run
summary and the job summary get it too, from one place.

Abbreviated to seven characters, as git does -- but only when it actually looks
like a hex sha. A tag or branch name is passed through whole: truncating one
would produce something that looks like a sha and is not.

`check.py` threads the existing `opts.sha`, which already feeds the archive name,
so nothing new has to be plumbed in.

219 platform tests pass, 5 new.
Two clean-ups, both of my own making.

`git add -A` in cbc397c swept in eighteen untracked files from the working tree
-- an unrelated ansible/jq/jmespath exploration under tests/providers/json/ --
and 9d0cc81 did the same with two of my session notes. None of it belongs to
SG-4885, and test_ansible_best_practices_jq.py fails ("operation_type: jq_query
is not supported"), which is what turned this PR's unittest and coverage jobs
red.

Removed with `git rm --cached`: every file stays on disk exactly as it was,
untracked and unchanged.

Then black over the files this branch actually owns. Measured on clean checkouts
rather than the working tree, because the working tree is full of untracked
files that skew it: main already fails black on 14 files, so the lint job was red
before this branch existed. This branch was adding nine more; those are fixed.
The pre-existing fourteen are deliberately left alone -- reformatting them is a
repo-wide decision, not this PR's, and it would bury the diff.

385 tests pass.
@refeed
refeed force-pushed the feat/gate-capable-engine branch from f32ff37 to b758b52 Compare August 6, 2026 08:38

@refeed refeed left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review focused on the masking path, since a leak there is the worst outcome in this feature. Five blocking findings, four of them leaks.

Blocking

1. resource_drift is never masked. src/tirith/platform/redact.py:198

redact_plan walks resource_changes and output_changes only. resource_drift is a top-level list of the same object shape (change.before/after, before_sensitive/after_sensitive) and is neither dropped nor masked. Verified: a plan with resource_drift[0].change.before = {"password":"hunter2"} and before_sensitive={"password":true} ships hunter2 in cleartext into the archive. Any terraform plan -refresh=true against a resource whose password drifted leaks it. No test mentions resource_drift.

2. Nested output sensitivity is ignored. redact.py:319

_redact_output_change masks a whole side only when sensitive/<side>_sensitive is True. Terraform emits structured markers for structured outputs. Verified: output_changes.conn = {"after":{"url":"x","password":"s3cret"},"after_sensitive":{"password":true}}s3cret survives. _mask_by_marker already handles this correctly; the output path just doesn't use it.

3. Raw state and plan files in the source tree are packed. archive.py:46,204

DEFAULT_EXCLUDES covers *.tfstate* only, and RESERVED_DOCUMENTS is matched against the root-relative path. Verified: state.json, tfplan-out.json and envs/plan.json all land in the tarball unmasked. So --state-path state.json --source-dir . — the exact flow the module docstring describes — uploads the masked copy as tfstate.json and the plaintext original as state.json. pack() is never told which paths were just masked.

This one got worse with the change making source-dir default to ..

4. redact_state silently no-ops on terraform show -json output. redact.py:341

That shape nests under values.root_module.resources, so neither branch fires: redaction count is 0, nothing is logged, and full plaintext state is packed and published as artifacts/tfstate.json. prepare_documents:100 warns for the inverse mistake but not this one.

5. An unreadable facts document renders as green. client.py:395check.py:299

get_run_facts returns {} on any non-200 and on any exception fetching the signed URL; get_results_artifact returns None on non-200. A COMPLETED run whose results cannot be fetched therefore produces empty policy_resultsverdict() = no-policies → exit 0, and the comment reads "no policies in scope". That is exactly the "green when the verdict is unknown" case the design exists to prevent. {} from a transport failure must be distinguishable from {} from an empty result.

Non-blocking

  • The "best-effort" state publish is not best-effort. check.py:196manages_terraform_state is an HTTP call sitting outside the try/except SGError, so a 401 or network failure there raises CheckError and kills the whole check before create_run. The test only fakes upload_file raising.
  • report.py:47rule.get("result", PASS) defaults a rule with no result key to a pass.
  • client.py:105 — non-idempotent POSTs are retried; create_run on a 504 after the run was created makes a second run, and the client polls only the second.

Tests

test_client.py:19/29/34 only re-assert membership in the constant under test — they pass even if wait_for_run ignored TERMINAL_STATUSES. test_wait_for_run_timeout_is_an_error_never_a_pass passes timeout=-1, so the loop never executes. test_archive.py:72 names state.json in its docstring as the motivating leak but parametrizes only the three reserved names — the named case is finding 3. run_check, which maps status → verdict → exit code, has no test at all.

Clean: regions.py, discover.py including the $GITHUB_OUTPUT wrapper guard, _mask_by_marker's positional list walk, and rebuild_planned_values — deletes excluded, replaces retained, modules grouped, values genuinely taken post-masking.


Reviewed by Claude Opus 5

Third attempt. The previous two were undone by a later `git add -A tests` in the same
session, which re-staged every file `git rm --cached` had just removed -- the files are
still on disk, so -A sees them as new. Added to .git/info/exclude locally so it cannot
happen again; not .gitignore, because they belong to someone else's in-flight work and
should stay visible on their branch.

17 files, unrelated to a gate-capable remote engine: ansible-lint and
ansible-best-practices fixtures, jq and JMESPath policy examples and READMEs.
test_ansible_best_practices_jq.py asserts an `operation_type: "jq_query"` that exists
nowhere in src/, so it fails 7 tests for anyone who checks this branch out.
@refeed refeed changed the title [SG-4885] feat(platform): tirith platform check — a pre-plan policy step, no platform changes [SG-4885] feat(remote): tirith remote check — a pre-plan policy step, no platform changes Aug 12, 2026
refeed added 7 commits August 12, 2026 21:56
The exit-codes section opened by explaining that the local form exits 0 either way and
only then mentioned --fail-on-error. That framing is for a reader protecting an existing
pipeline; a newcomer has none, and it reads as an apology for something that now works.

Gating comes first, with the command. The default is a one-line note after it, which is
where a compatibility caveat belongs. Also merged the two paragraphs that were both
explaining 3-vs-1 in slightly different words.
…e files I touched

**The 3.8 and 3.9 unittest jobs were failing on a test I wrote**, and for a reason the test
itself created. `test_the_usage_block_is_the_real_help_output` compared the README's Usage
block byte for byte against `tirith --help` -- but argparse renamed its section header from
"optional arguments:" to "options:" in 3.10, so a block generated on any one interpreter
cannot match on the other half of the matrix. It passed on 3.10-3.12 and failed on 3.8-3.9,
which is the worst shape for a guard test: it looks like it works.

Now compares the *set of option strings* in both directions -- accepted but undocumented,
documented but not accepted. That is the thing the test existed to catch (a `-var-path` that
shipped undocumented), and it does not depend on how argparse decides to lay out a heading.
Verified against a simulated 3.8 header.

**Black.** Four files, all touched by this PR: core.py, platform/check.py,
tests/platform/test_client.py, tests/platform/test_report.py. Formatted only those --
`origin/main` itself fails Black on 14 files because CI pins `psf/black@stable` (unpinned,
so whatever is newest) against a tree formatted by an older release, and reformatting the
other ten here would bury this PR in an unrelated diff. That drift is worth fixing on main
by pinning the version, separately.
The Black job was red on this PR and on `main` alike, on 14 files nobody had touched. Cause:
`psf/black@stable` resolves to whatever Black is newest when the job runs, so a Black
release reformats the world and every open branch goes red with nothing in the repository
having changed. main last passed this job in November 2025 and fails it today.

Pinned to 25.1.0, which is the release the tree is actually formatted for -- verified by
running it against `origin/main`, which comes back clean, and against 24.10.0, which also
does. Bumping it should be a deliberate commit that reformats, not a surprise from
upstream.

Reverted my earlier attempt to fix this by formatting four files with 26.5.1: that was
chasing the newest release rather than the pinned one, and would have left the tree
inconsistent with the other 85 files. The tree is now clean under 25.1.0 end to end.
archive.pack raises ArchiveError both for an oversized archive and for a source directory
that does not exist, and pack_documents' degrade path only knew about the first. So a
typo'd --source-dir was reported as "the tree was too large", the code was dropped, and
the run completed -- a check that passed having evaluated no source at all, with the
bundle's own metadata.json stating the wrong reason for its absence.

Checked before packing, where the two are still distinguishable. A missing directory is a
user error and should stop the run; only a genuinely oversized tree degrades.
…st F2)

A secret used in a resource tag was masked at `tags.Password` and uploaded in **cleartext**
at `tags_all.Password`. Both state shapes leaked, and the bundle is retained indefinitely,
so the plaintext outlived the run.

`redact_plan` has swept for this since the equivalent plan leak was found: it collects the
plaintext of every marked-sensitive value and replaces that value everywhere in the
document, precisely because a provider writes computed *mirrors* of an attribute carrying
the same secret with no sensitivity marker of their own. `tags_all` is the confirmed case
-- terraform does not propagate sensitivity into values it computes for you.

`redact_state` masked by marker and returned. Same hole, worse place: state carries every
attribute of every resource, not just what a plan surfaces. Found by a penetration test
(F2, Medium), reproduced code-level, and reproduced again here against both shapes before
and after.

It now collects at all four masking sites and sweeps the whole document on the way out:

  show -json resources    _collect_sensitive_values against `sensitive_values` -- the same
                          marker convention as a plan, so this is a direct reuse
  raw instances           read the plaintext at each resolved `sensitive_attributes` path
  outputs, both shapes    collect `output.value` before it is replaced

The raw shape needed a path *reader*: `_mask_attribute_path` walks to a leaf and assigns,
with nothing to read the same path back. `_read_attribute_path` mirrors that walk exactly,
because the two have to agree on what a path means or the sweep collects a different value
from the one that was masked. It reads from the untouched original rather than the copy
being masked -- after the first path is masked the copy holds the sentinel there, and
sweeping for that would do nothing.

Outputs are worth noting separately: an output's plaintext was discarded when it was
masked, so nothing else knew it was a secret, and the same value in an ordinary attribute
stayed in cleartext.

Six tests, in both shapes, all failing before this change. Including the two bounds that
keep a value-based sweep honest: `region` survives, and a value under
MIN_SWEPT_SECRET_LENGTH is not swept, so masking `Env: dev` does not redact every `dev` in
the document.
…pentest F1)

Every attacker-influenced string in the report was interpolated raw or wrapped in a single
backtick, and a backtick *in the value* closes that span so the remainder renders as
markdown and HTML. A pull-request author controls the terraform a plan is built from, so
they controlled the report a reviewer reads: the pen test produced a fake "all policies
passed" banner and a link whose text said app.stackguardian.io and whose href pointed
elsewhere. Reviewers get that by email too. The verdict and the exit code were never
affected -- this is report corruption, not a gate bypass.

The report named three sinks. There are eight, and two it did not mention:
the infracost `currency` and the `str(monthly)` fallback go inside `<sub>`, and the run URL
goes inside an `href`. Also `rule_name` was the only field with no wrapping *at all*, in a
table cell and inside `<summary>` -- the strongest sink in the file.

Two primitives, because the sinks are in two different languages:

`_code()` for markdown contexts. A code span whose fence is one backtick longer than the
longest run inside the value, per CommonMark, so it cannot be closed from within. Inert by
construction rather than by enumerating dangerous characters. Escaping was the alternative
and is worse here: the engine deliberately puts backticks in its own messages
(`json_format_value` wraps every compared value), so escaping them puts visible backslashes
through every finding, and it holds only while the character list stays complete. Newlines
collapse to a space, and pipes become `\|` in table cells -- GFM's documented escape and
the one that works inside a span.

`_html()` for values going into `<summary>`, `<code>`, `<sub>` and the `href`. A code span
is wrong there: GFM does not reliably render markdown inside inline HTML. It also escapes
backticks, which `html.escape` does not -- they cannot close a span in the summary because
there is none, but an odd one OPENS one that swallows the markdown after it, so
``cost-control` `` still distorted the report with the tags already neutralised. Found while
verifying, not while designing.

`check.py` also now quotes org, workflow group and workflow id into the run URL, the way
client.py already quotes the same three on every API path.

Nine tests, all failing before this change, asserting against markdown **rendered by a
CommonMark parser** rather than against the source -- the payload is still in the source by
design, inside a span where it is inert, so a substring check on the source proves nothing.
That was the mistake I made first while verifying this.

One visible change beyond the fix: `rule_name` now renders in a code span like `policy_id`
already did, so ordinary rows gain backticks around the rule. Consistent, but not
byte-identical to before -- I had assumed benign output would be unchanged and a test proved
otherwise.
The rename to `remote` was made on the argument that "platform check" can read as *a check
of the platform*, which is what `--platform` means in most tools. Reverted: the vagueness
is minor next to the cost of having two names in circulation, and the concern that
prompted it was really that the open-source surface could not gate at all -- which
`--fail-on-error` fixed, and no rename would have.

No alias in either direction. Nothing is released -- py-tirith is not on PyPI and the
action pins a branch -- so there was never a caller to keep working, which is what made
both this and the original rename cheap.

`docs/remote-check.md` moves back with git mv so the history follows, and both embedded
`--help` blocks are regenerated under the restored name. The dispatch test now pins the
outcome rather than the direction: exactly one subcommand name, and `remote` is not
quietly still accepted.

Kept from the rename work, because neither depended on the name: the one-column
continuation fix in `--help`, and `status.py` no longer naming a subcommand at all in its
exit-code comment.
@refeed refeed changed the title [SG-4885] feat(remote): tirith remote check — a pre-plan policy step, no platform changes [SG-4885] feat(platform): tirith platform check — a pre-plan policy step, no platform changes Aug 12, 2026
AkashS0510
AkashS0510 previously approved these changes Aug 13, 2026
arunim2405
arunim2405 previously approved these changes Aug 13, 2026
The opening still described a StackGuardian-coupled policy framework, which is
no longer what this is. Lead with what it does to a pipeline and with the reason
it is a plugin -- one policy set covering every CI system you run it from -- and
move StackGuardian to one late mention as the optional platform mode.

Also: a pinned-version install example, a note that PyPI's `tirith` is an
unrelated project so nobody installs the wrong package, and a CI section with
the two-line GitHub Actions form and a GitLab job, since the CLI is the only
route on non-GitHub runners.
refeed added 3 commits August 13, 2026 19:28
* docs: publish the documentation site, and give it a real homepage

The deploy workflow built the Docusaurus site and then published `./build`,
which does not exist -- the site is built at `./documentation/build`. So every
push to main published an empty directory, which is why the gh-pages branch
holds nothing but .nojekyll and https://stackguardian.github.io/tirith/ has
always returned 404.

Fixing publish_dir is the whole fix. Alongside it:

- url and baseUrl were still the create-docusaurus placeholders. A project site
  is served under /<projectName>/, so baseUrl has to be /tirith/ or every asset
  and link on the deployed site resolves to the wrong path.

- The homepage was the untouched scaffold: the hero rendered the single word
  "Tirith", and HomepageFeatures rendered nothing at all because its FeatureList
  was entirely commented out. It now carries the landing-page copy, derived from
  README.md so there is one source of truth. All prose sits in one `content`
  object apart from the markup, so it can be edited without reading JSX. The
  dead HomepageFeatures component is removed.

- npm ci rather than yarn install, in both workflows: package-lock.json is the
  committed lockfile and there is no yarn.lock, so yarn ignored it and resolved
  the dependency tree fresh on every deploy.

- A new build_docs.yml builds the site on pull requests. The site sets
  onBrokenLinks: 'throw', so one bad cross-link fails the build -- previously
  discoverable only after merging to main.

* docs: add reference documentation for providers, evaluators and the CLI

The site documented how to write a policy but never said what you could
actually put in one: no list of providers, no list of operation types and their
arguments, no list of condition types, and no CLI or exit-code reference. This
adds 14 pages covering all of it.

Every claim is taken from the code rather than from the existing prose, and the
examples were executed rather than written from memory -- 6 cookbook recipes,
11 provider policies and ~88 evaluator probes were run against the real CLI,
and the quoted output and exit codes are what came back.

New:

  tirith-usage/       cli-reference, exit-codes, ci-integration, platform-check
  tirith-providers/   overview + one page per provider, with every operation
                      type, its arguments and what it returns
  tirith-reference/   evaluators (all 13 condition types) and eval-expressions
  tirith-policies/    tirith-policy-reference (field-by-field schema) and
                      tirith-policy-cookbook (6 executed recipes)

Fixed in the existing pages:

- tirith-policy-variables.md used `{{ max_epoch }}`, but the engine's pattern is
  `{{ var.NAME }}`. Following that page produced a policy that compared against
  the literal placeholder string instead of the variable -- a check that looks
  like it passes while measuring nothing. Its policy JSON was also invalid
  (missing comma) and lacked the required eval_expression. The corrected
  version is one that was run; the quoted output is its real output.

- Cross-links now point at the .md file rather than the URL. Relative URLs
  resolve against the page's own directory, so `../tirith-reference/evaluators`
  from a page at /docs/tirith-providers/x/ resolved to
  /docs/tirith-providers/tirith-reference/evaluators. Linking by file lets
  Docusaurus resolve through the file graph, which also survives a slug change.

- sidebars.js is a manual sidebar, so the new pages are registered there under
  Using Tirith, Providers and Reference; without an entry a page builds but is
  unreachable in navigation.

meta.enforcement is documented as what it is: inert in the engine, and read by
the layer above it. The CLI copies it through untouched, while the GitHub Action
downgrades a failing policy to a warning for soft_mandatory and friends and
blocks on anything it does not recognise.

* docs: point the navbar logo at the site, not the policy builder

Clicking a site's own logo goes to that site's home. This one opened
tirith-policy-builder.vercel.app in a new tab, so the one control every reader
expects to take them home instead took them off the site, with no way back.

The builder is still reachable -- it moves to a named navbar item next to
GitHub, which is a clearer place for it than an unlabelled logo. The in-content
link on the getting-started page is untouched; that site is live and the
reference there is deliberate.

* docs: drop the Azure DevOps disclaimer

Listing a system purely to say it is unsupported tells a reader nothing the
"Works with" list does not already tell them, and reads as a roadmap hint that
was never intended. Removed from all three places it appeared: the landing page,
the CI integration page and the README.

Nothing is claimed about Azure DevOps either way now, which was the point of
mentioning it in the first place.

* docs: fix the invisible label on the Get started button

The hand-rolled button rule set the label to var(--ifm-background-color), which
resolves to #0000 in light mode -- fully transparent. The result was a purple
rectangle with no readable text in it.

Replaced with Docusaurus's own button classes, which resolve their foreground
through --ifm-button-color to white over the dark purple in light mode and to
near-black over the lighter purple in dark mode. That removes the custom rules
rather than patching them, so there is one less place to get contrast wrong.
Pages is enabled on this repo but has never served anything -- the gh-pages
branch contains only .nojekyll, so https://stackguardian.github.io/tirith/
returns 404. This is a stopgap until the designer rebuilds it next week.

Deliberately plain: one hand-written HTML file, no generator, no build step and
no JavaScript, because the repo has no site tooling and a placeholder is a bad
reason to introduce some. Every prose block sits in its own commented <section>
so the copy can be lifted out without reading the markup.

All copy is derived from README.md rather than newly written, so there is one
source of truth to keep correct. Claims are limited to what the code actually
does: no OPA (nothing in src/ references it), and no approvals, since a policy
asking for approval warns rather than gates.

Serving this needs a one-time Pages settings change -- source from a branch,
folder /docs, replacing the empty gh-pages branch. docs/.nojekyll keeps Jekyll
off the existing .md and .gif files in that folder.
@sonarqubecloud

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@arunim2405 arunim2405 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review at 2ee8727: the penetration-test fixes for report spoofing and state computed-mirror leakage are solid and test-backed. However, the current head still has three potential secret/state-loss paths and one merge-gate integrity race. I reproduced the credential and plan-output leaks directly against this commit. Please address the inline findings before merge.

Verification: security-focused platform suite passed (238 passed, 6 skipped) on the previously reviewed code head; the four commits since then are documentation-only. GitHub unit, lint, coverage, and CodeQL checks are green.

Comment thread src/tirith/platform/check.py
Comment thread src/tirith/platform/client.py
Comment thread src/tirith/platform/redact.py
Comment thread src/tirith/platform/check.py
@refeed refeed changed the title [SG-4885] feat(platform): tirith platform check — a pre-plan policy step, no platform changes [SG-4885] feat(platform): tirith platform check — a pre-plan policy step, no platform changes, plus the reference docs and a site that publishes Aug 13, 2026
@refeed
refeed merged commit 5e690fb into main Aug 13, 2026
21 of 22 checks passed
@refeed
refeed deleted the feat/gate-capable-engine branch August 13, 2026 13:01
refeed added a commit to StackGuardian/tirith-iac-governance-action that referenced this pull request Aug 13, 2026
The action installed py-tirith from `feat/gate-capable-engine`, which was
deleted when StackGuardian/tirith#272 merged. That ref no longer resolves, so
`pip install` fails and the action is broken outright -- for callers and for
this repository's own CI.

Cutting the 1.2.0 tag was the release step this line was always waiting on, so
the branch becomes a real pin rather than needing a stopgap.

Two install sites had to change: action.yml and the CI workflow. The existing
test's docstring already named the CI workflow as exactly where a version skew
hides, and then did not look at it -- so it hid there. That test now asserts
both files pin the same ref, and a new one asserts the ref is a release tag
rather than a branch. Both were confirmed to fail before they passed.
refeed added a commit to StackGuardian/tirith-iac-governance-action that referenced this pull request Aug 13, 2026
The action installed py-tirith from `feat/gate-capable-engine`, which was
deleted when StackGuardian/tirith#272 merged. That ref no longer resolves, so
`pip install` fails and the action is broken outright -- for callers and for
this repository's own CI.

Cutting the 1.2.0 tag was the release step this line was always waiting on, so
the branch becomes a real pin rather than needing a stopgap.

Two install sites had to change: action.yml and the CI workflow. The existing
test's docstring already named the CI workflow as exactly where a version skew
hides, and then did not look at it -- so it hid there. That test now asserts
both files pin the same ref, and a new one asserts the ref is a release tag
rather than a branch. Both were confirmed to fail before they passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants